Skip to content

feat: optimize ENS avatar images with Sharp#601

Open
rickstaa wants to merge 1 commit intomainfrom
feat/optimize-ens-avatar-images
Open

feat: optimize ENS avatar images with Sharp#601
rickstaa wants to merge 1 commit intomainfrom
feat/optimize-ens-avatar-images

Conversation

@rickstaa
Copy link
Member

Summary

  • Add reusable optimizeImage utility in lib/api/image-optimization.ts
  • Integrate Sharp-based optimization into /api/ens-data/image/[name] endpoint
  • Resize ENS avatars from 704×704 to 96×96 and convert to WebP format (~95% size reduction)
  • Graceful fallback to original image if optimization fails
  • Add sharp dependency

Extracted from #509 by @Roaring30s — Lighthouse performance improvements.

Partially addresses #433.

Test plan

  • Visit an orchestrator profile with an ENS avatar
  • Verify image loads correctly in WebP format (check network tab)
  • Verify image dimensions are 96×96
  • Test with an ENS name that has no avatar (should still return 404 gracefully)
  • Test with corrupted/unsupported image format (should fallback to original)

🤖 Generated with Claude Code

Add image optimization utility that resizes ENS avatars from 704×704
to 96×96 and converts to WebP format, reducing file sizes by ~95%.
Includes graceful fallback to original image on optimization failure.

Extracted from #509.

Co-Authored-By: Sebastian <115311276+Roaring30s@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@rickstaa rickstaa requested a review from ECWireless as a code owner March 25, 2026 11:08
Copilot AI review requested due to automatic review settings March 25, 2026 11:08
@vercel
Copy link
Contributor

vercel bot commented Mar 25, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
explorer-arbitrum-one Ready Ready Preview, Comment Mar 25, 2026 11:10am

Request Review

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces server-side ENS avatar image optimization so the Explorer can serve smaller, faster-loading avatar images via the existing /api/ens-data/image/[name] endpoint.

Changes:

  • Add a reusable Sharp-based optimizeImage utility for resizing/converting images to WebP.
  • Integrate the optimizer into the ENS avatar image API route (targeting 96×96 WebP).
  • Add the sharp dependency (and lockfile updates).

Reviewed changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated 7 comments.

File Description
lib/api/image-optimization.ts Adds a Sharp-powered image optimization helper returning optimized bytes + metadata.
pages/api/ens-data/image/[name].tsx Uses optimizeImage to serve optimized ENS avatars from the API endpoint.
package.json Adds sharp as a dependency.
pnpm-lock.yaml Locks sharp and related transitive dependencies.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +60 to +67
// Set appropriate content type (fallback to original if optimization failed)
if (optimizationResult.contentType === "image/jpeg") {
const originalContentType =
response.headers.get("content-type") || "image/jpeg";
res.setHeader("Content-Type", originalContentType);
} else {
res.setHeader("Content-Type", optimizationResult.contentType);
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using optimizationResult.contentType === "image/jpeg" as a sentinel for "optimization failed" is brittle and couples the route to the utility's implementation details. Consider returning an explicit flag (e.g., optimized: boolean) and/or originalContentType from optimizeImage so the caller can set headers without relying on a magic content type.

Suggested change
// Set appropriate content type (fallback to original if optimization failed)
if (optimizationResult.contentType === "image/jpeg") {
const originalContentType =
response.headers.get("content-type") || "image/jpeg";
res.setHeader("Content-Type", originalContentType);
} else {
res.setHeader("Content-Type", optimizationResult.contentType);
}
// Set appropriate content type, preferring optimized result and falling back to original if needed
const resolvedContentType =
optimizationResult.contentType ||
response.headers.get("content-type") ||
"image/jpeg";
res.setHeader("Content-Type", resolvedContentType);

Copilot uses AI. Check for mistakes.
Comment on lines +30 to +34
const { width = 96, height = 96, quality = 75, effort = 6 } = options;

const originalSize = imageBuffer.byteLength;
const originalBuffer = Buffer.from(imageBuffer);

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optimizeImage processes untrusted, externally-fetched image bytes with Sharp without any explicit limits. Consider adding guardrails (max input byte size and/or limitInputPixels) to reduce risk of decompression bombs / CPU+memory DoS, and skip optimization (or reject) when limits are exceeded.

Copilot uses AI. Check for mistakes.
Comment on lines +35 to +39
// Get original image metadata
let originalMetadata;
try {
originalMetadata = await sharp(originalBuffer).metadata();
} catch {
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sharp(originalBuffer).metadata() is called and then the image is decoded again during the resize/WebP conversion. This doubles decoding work for every request. Consider reusing a single Sharp pipeline (or making original dimension capture optional) to avoid the extra decode.

Copilot uses AI. Check for mistakes.
Comment on lines +79 to +82
return {
buffer: originalBuffer,
contentType: "image/jpeg", // Default fallback
originalSize,
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the fallback path, contentType is hard-coded to image/jpeg. If this utility is reused elsewhere, callers may inadvertently serve non-JPEG bytes with the wrong Content-Type. Prefer returning contentType: null/undefined on fallback (forcing the caller to supply the original), or accept the original content-type as an input and echo it back when optimization fails.

Copilot uses AI. Check for mistakes.
};
} catch {
// Fallback to original image if optimization fails
// This is expected for some edge cases (unsupported formats, corrupted images, etc.
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment typo: the parenthesis is unclosed in “(unsupported formats, corrupted images, etc.”.

Suggested change
// This is expected for some edge cases (unsupported formats, corrupted images, etc.
// This is expected for some edge cases (unsupported formats, corrupted images, etc.)

Copilot uses AI. Check for mistakes.
@@ -48,9 +49,30 @@ const handler = async (

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetch(imageUrl) is followed by unconditional response.arrayBuffer() and optimization. If the upstream returns a 404/500 or a non-image content-type (e.g. text/html), this route will still return 200 with that body (and may attempt Sharp decoding). Add a response.ok check and validate Content-Type starts with image/ before reading/processing; otherwise return notFound/externalApiError appropriately.

Suggested change
// Ensure the upstream request succeeded
if (!response.ok) {
if (response.status === 404) {
return notFound(res, "ENS avatar not found");
}
return internalError(
res,
new Error(
`Failed to fetch ENS avatar: ${response.status} ${response.statusText}`
)
);
}
// Ensure the upstream response is an image
const upstreamContentType =
response.headers.get("content-type")?.toLowerCase() || "";
if (!upstreamContentType.startsWith("image/")) {
return notFound(res, "ENS avatar not found");
}

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +57
const optimizationResult = await optimizeImage(arrayBuffer, {
width: 96,
height: 96,
quality: 75,
effort: 6,
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint now always returns WebP on success. To avoid breaking clients/crawlers that don't send Accept: image/webp, consider negotiating based on the request Accept header (and returning the original bytes otherwise), and set Vary: Accept when the response format can change.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

@vercel vercel bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Suggestions:

  1. ENS blacklist check is case-sensitive, allowing attackers to bypass it using different case variations of blacklisted names
  1. Fallback ENS avatar URL uses non-normalized name instead of normalized version, causing inconsistency with the primary ENS API call and potential lookup failures on the metadata service

Fix on Vercel

* @returns Optimized image buffer and metadata, or original buffer if optimization fails
*/
export async function optimizeImage(
imageBuffer: ArrayBuffer,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing parameter validation in optimizeImage function allows Sharp to throw unhandled errors when invalid width, height, quality, or effort values are provided

Fix on Vercel

: undefined,
format: optimizedMetadata?.format,
};
} catch {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image optimization fallback hardcodes 'image/jpeg' content-type regardless of actual image format, causing wrong content-type headers for PNG, GIF, and other non-JPEG images when optimization fails

Fix on Vercel


const arrayBuffer = await response.arrayBuffer();

// Optimize image using utility
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing response status check allows error responses to be processed as image data, causing Sharp to fail silently and return HTML/JSON with image content-type headers

Fix on Vercel

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Triage

Development

Successfully merging this pull request may close these issues.

2 participants